Master domein-gedreven ontwerp in JavaScript. Leer het Module Entity Pattern om schaalbare, testbare en onderhoudbare applicaties te bouwen met robuuste domein object modellen.
JavaScript Module Entity Patterns: Een Diepe Duik in Domein Object Modeling
In de wereld van software ontwikkeling, vooral binnen het dynamische en constant evoluerende JavaScript ecosysteem, prioriteren we vaak snelheid, frameworks en features. We bouwen complexe user interfaces, verbinden met talloze API's, en deployen applicaties in een duizelingwekkend tempo. Maar in deze haast verwaarlozen we soms de kern van onze applicatie: het business domein. Dit kan leiden tot wat vaak de "Big Ball of Mud" wordt genoemdāeen systeem waar business logica verspreid is, data ongestructureerd is, en het maken van een simpele verandering een cascade van onvoorziene bugs kan triggeren.
Dit is waar Domein Object Modeling om de hoek komt kijken. Het is de praktijk van het creƫren van een rijk, expressief model van de probleemruimte waarin je werkt. En in JavaScript is het Module Entity Pattern een krachtige, elegante, en framework-agnostische manier om dit te bereiken. Deze uitgebreide gids neemt je mee door de theorie, praktijk en voordelen van dit patroon, waardoor je meer robuuste, schaalbare en onderhoudbare applicaties kunt bouwen.
Wat is Domein Object Modeling?
Voordat we in het patroon zelf duiken, laten we onze termen verduidelijken. Het is cruciaal om dit concept te onderscheiden van het Document Object Model (DOM) van de browser.
- Domein: In software is het 'domein' het specifieke onderwerpgebied waar het bedrijf van de gebruiker toe behoort. Voor een e-commerce applicatie omvat het domein concepten zoals Producten, Klanten, Orders en Betalingen. Voor een social media platform omvat het Gebruikers, Posts, Commentaren en Likes.
- Domein Object Modeling: Dit is het proces van het creƫren van een software model dat de entiteiten, hun gedragingen en hun relaties binnen dat business domein vertegenwoordigt. Het gaat om het vertalen van real-world concepten naar code.
Een goed domein model is niet zomaar een verzameling van data containers. Het is een levende representatie van je business regels. Een Order object zou niet alleen een lijst met items moeten bevatten; het zou moeten weten hoe het totaal berekend moet worden, hoe een nieuw item toegevoegd kan worden, en of het geannuleerd kan worden. Deze encapsulatie van data en gedrag is de sleutel tot het bouwen van een veerkrachtige applicatie kern.
Het Algemene Probleem: Anarchie in de "Model" Laag
In veel JavaScript applicaties, vooral die organisch groeien, is de 'model' laag vaak een bijzaak. We zien vaak dit anti-patroon:
// Ergens in een API controller of service...
async function createUser(req, res) {
const { email, password, firstName, lastName } = req.body;
// Business logica en validatie is hier verspreid
if (!email || !email.includes('@')) {
return res.status(400).send({ error: 'Een geldig e-mailadres is vereist.' });
}
if (!password || password.length < 8) {
return res.status(400).send({ error: 'Wachtwoord moet minstens 8 tekens lang zijn.' });
}
const user = {
email: email.toLowerCase(),
password: await hashPassword(password), // Een utility functie
fullName: `${firstName} ${lastName}`, // Logica voor afgeleide data staat hier
createdAt: new Date()
};
// Wat is `user` nu? Het is slechts een plain object.
// Niets weerhoudt een andere ontwikkelaar ervan om later dit te doen:
// user.email = 'een-ongeldig-email';
// user.password = 'kort';
await db.users.insert(user);
res.status(201).send(user);
}
Deze aanpak presenteert verschillende kritieke problemen:
- Geen Single Source of Truth: De regels voor wat een geldige 'user' is, zijn gedefinieerd binnen deze ene controller. Wat als een ander deel van het systeem een user moet aanmaken? Kopieer je de logica? Dit leidt tot inconsistentie en bugs.
- Anemisch Domein Model: Het `user` object is slechts een 'domme' zak data. Het heeft geen gedrag en geen zelfbewustzijn. Alle logica die erop werkt, leeft extern.
- Lage Cohesie: De logica voor het aanmaken van de volledige naam van een user is vermengd met API request/response handling en password hashing.
- Moeilijk te Testen: Om de user creation logica te testen, moet je HTTP requests en responses, databases en hashing functies mocken. Je kunt het 'user' concept niet zomaar in isolatie testen.
- Impliciete Contracten: De rest van de applicatie moet gewoon 'aannemen' dat elk object dat een user vertegenwoordigt een bepaalde vorm heeft en dat de data geldig is. Er zijn geen garanties.
De Oplossing: Het JavaScript Module Entity Pattern
Het Module Entity Pattern adresseert deze problemen door een standaard JavaScript module (ƩƩn bestand) te gebruiken om alles over een enkel domein concept te definiƫren. Deze module wordt de definitieve bron van waarheid voor die entity.
Een Module Entity exposeert typisch een factory functie. Deze functie is verantwoordelijk voor het creƫren van een geldige instantie van de entity. Het object dat het retourneert is niet alleen data; het is een rijk domein object dat zijn eigen data, validatie en business logica inkapselt.
Belangrijkste Kenmerken van een Module Entity
- Encapsulatie: Het bundelt data en de functies die op die data werken samen.
- Validatie aan de Grens: Het zorgt ervoor dat het onmogelijk is om een ongeldige entity te creƫren. Het bewaakt zijn eigen staat.
- Duidelijke API: Het exposeert een schone, intentionele set functies (een publieke API) voor interactie met de entity, terwijl interne implementatiedetails verborgen blijven.
- Immutable: Het produceert vaak immutable of read-only objecten om accidentele statusveranderingen te voorkomen en voorspelbaar gedrag te garanderen.
- Portable: Het heeft geen afhankelijkheden van frameworks (zoals Express, React) of externe systemen (zoals databases, API's). Het is pure business logica.
Kerncomponenten van een Module Entity
Laten we ons `User` concept herbouwen met behulp van dit patroon. We maken een bestand, `user.js` (of `user.ts` voor TypeScript gebruikers), en bouwen het stap voor stap.
1. De Factory Functie: Je Object Constructor
In plaats van classes, gebruiken we een factory functie (bijv., `buildUser`). Factories bieden veel flexibiliteit, vermijden worstelingen met het `this` keyword, en maken private state en encapsulatie natuurlijker in JavaScript.
Ons doel is om een functie te creƫren die raw data accepteert en een goed gevormd, betrouwbaar User object retourneert.
// bestand: /domain/user.js
export default function buildMakeUser() {
// Deze inner functie is de daadwerkelijke factory.
// Het heeft toegang tot alle afhankelijkheden die aan buildMakeUser zijn doorgegeven, indien nodig.
return function makeUser({
id = generateId(), // Laten we een functie aannemen om een unieke ID te genereren
firstName,
lastName,
email,
passwordHash,
createdAt = new Date()
}) {
// ... validatie en logica komen hier ...
const user = {
getId: () => id,
getFirstName: () => firstName,
getLastName: () => lastName,
getEmail: () => email,
getPasswordHash: () => passwordHash,
getCreatedAt: () => createdAt
};
// Object.freeze gebruiken om het object immutable te maken.
return Object.freeze(user);
}
}
Let op een paar dingen hier. We gebruiken een functie die een functie retourneert (een higher-order functie). Dit is een krachtig patroon voor het injecteren van afhankelijkheden, zoals een unieke ID generator of een validator library, zonder de entity aan een specifieke implementatie te koppelen. Voor nu houden we het simpel.
2. Data Validatie: De Bewaker bij de Poort
Een entity moet zijn eigen integriteit beschermen. Het zou onmogelijk moeten zijn om een `User` in een ongeldige staat te creƫren. We voegen validatie rechtstreeks in de factory functie toe. Als de data ongeldig is, moet de factory een error gooien, duidelijk aangevend wat er mis is.
// bestand: /domain/user.js
export default function buildMakeUser({ Id, isValidEmail, hashPassword }) {
return function makeUser({
id = Id.makeId(),
firstName,
lastName,
email,
password, // We nemen nu een plain password en handelen het intern af
createdAt = new Date()
}) {
if (!Id.isValidId(id)) {
throw new Error('User moet een geldig id hebben.');
}
if (!firstName || firstName.length < 2) {
throw new Error('Voornaam moet minstens 2 tekens lang zijn.');
}
if (!lastName || lastName.length < 2) {
throw new Error('Achternaam moet minstens 2 tekens lang zijn.');
}
if (!email || !isValidEmail(email)) {
throw new Error('User moet een geldig e-mailadres hebben.');
}
if (!password || password.length < 8) {
throw new Error('Wachtwoord moet minstens 8 tekens lang zijn.');
}
// Data normalisatie en transformatie gebeurt hier
const passwordHash = hashPassword(password);
const normalizedEmail = email.toLowerCase();
return Object.freeze({
getId: () => id,
getFirstName: () => firstName,
getLastName: () => lastName,
getEmail: () => normalizedEmail,
getPasswordHash: () => passwordHash,
getCreatedAt: () => createdAt
});
}
}
Nu moet elk deel van ons systeem dat een `User` wil creƫren via deze factory gaan. We krijgen gegarandeerde validatie elke keer. We hebben ook de logica van het hashen van het password en het normaliseren van het e-mailadres ingekapseld. De rest van de applicatie hoeft deze details niet te weten of zich erom te bekommeren.
3. Business Logica: Gedrag Inkapselen
Ons `User` object is nog steeds een beetje anemisch. Het bevat data, maar het *doet* niets. Laten we gedrag toevoegenāmethoden die domein-specifieke acties vertegenwoordigen.
// ... binnen de makeUser functie ...
if (!password || password.length < 8) {
// ...
}
const passwordHash = hashPassword(password);
const normalizedEmail = email.toLowerCase();
return Object.freeze({
getId: () => id,
getFirstName: () => firstName,
getLastName: () => lastName,
getEmail: () => normalizedEmail,
getPasswordHash: () => passwordHash,
getCreatedAt: () => createdAt,
// Business Logica / Gedrag
getFullName: () => `${firstName} ${lastName}`,
// Een methode die een business rule beschrijft
canVote: () => {
// In sommige landen is de stemgerechtigde leeftijd 18. Dit is een business rule.
// Laten we aannemen dat we een dateOfBirth property hebben.
const age = calculateAge(dateOfBirth);
return age >= 18;
}
});
// ...
De `getFullName` logica is niet langer verspreid in een willekeurige controller; het behoort tot de `User` entity zelf. Iedereen met een `User` object kan nu betrouwbaar de volledige naam ophalen door `user.getFullName()` aan te roepen. De logica is ƩƩn keer gedefinieerd, op ƩƩn plaats.
Een Praktisch Voorbeeld Bouwen: Een Simpel E-commerce Systeem
Laten we dit patroon toepassen op een meer onderling verbonden domein. We modelleren een `Product`, een `OrderItem`, en een `Order`.
1. De `Product` Entity Modelleren
Een product heeft een naam, een prijs en wat voorraadinformatie. Het moet een naam hebben, en de prijs mag niet negatief zijn.
// bestand: /domain/product.js
export default function buildMakeProduct({ Id }) {
return function makeProduct({
id = Id.makeId(),
name,
description,
price,
stock = 0
}) {
if (!Id.isValidId(id)) {
throw new Error('Product moet een geldige ID hebben.');
}
if (!name || name.trim().length < 2) {
throw new Error('Productnaam moet minstens 2 tekens zijn.');
}
if (isNaN(price) || price <= 0) {
throw new Error('Product moet een prijs hebben groter dan nul.');
}
if (isNaN(stock) || stock < 0) {
throw new Error('Voorraad moet een niet-negatief getal zijn.');
}
return Object.freeze({
getId: () => id,
getName: () => name,
getDescription: () => description,
getPrice: () => price,
getStock: () => stock,
// Business logica
isAvailable: () => stock > 0,
// Een methode die de staat wijzigt door een nieuwe instantie te retourneren
reduceStock: (amount) => {
if (amount > stock) {
throw new Error('Niet genoeg voorraad beschikbaar.');
}
// Retourneer een NIEUW product object met de bijgewerkte voorraad
return makeProduct({ id, name, description, price, stock: stock - amount });
}
});
}
}
Let op de `reduceStock` methode. Dit is een cruciaal concept gerelateerd aan immutability. In plaats van de `stock` property op het bestaande object te wijzigen, retourneert het een *nieuwe* `Product` instantie met de bijgewerkte waarde. Dit maakt statusveranderingen expliciet en voorspelbaar.
2. De `Order` Entity Modelleren (De Aggregate Root)
Een `Order` is complexer. Het is wat Domain-Driven Design (DDD) een "Aggregate Root" noemt. Het is een entity die andere, kleinere objecten binnen zijn grens beheert. Een `Order` bevat een lijst met `OrderItem`s. Je voegt een product niet rechtstreeks toe aan een order; je voegt een `OrderItem` toe die een product en een hoeveelheid bevat.
// bestand: /domain/order.js
export const ORDER_STATUS = {
PENDING: 'PENDING',
PAID: 'PAID',
SHIPPED: 'SHIPPED',
CANCELLED: 'CANCELLED'
};
export default function buildMakeOrder({ Id, validateOrderItem }) {
return function makeOrder({
id = Id.makeId(),
customerId,
items = [],
status = ORDER_STATUS.PENDING,
createdAt = new Date()
}) {
if (!Id.isValidId(id)) {
throw new Error('Order moet een geldige ID hebben.');
}
if (!customerId) {
throw new Error('Order moet een klant ID hebben.');
}
let orderItems = [...items]; // Maak een private kopie om te beheren
return Object.freeze({
getId: () => id,
getCustomerId: () => customerId,
getItems: () => [...orderItems], // Retourneer een kopie om externe aanpassing te voorkomen
getStatus: () => status,
getCreatedAt: () => createdAt,
// Business Logica
calculateTotal: () => {
return orderItems.reduce((total, item) => {
return total + (item.getPrice() * item.getQuantity());
}, 0);
},
addItem: (item) => {
// validateOrderItem is een functie die ervoor zorgt dat het item een geldige OrderItem entity is
validateOrderItem(item);
// Business rule: voorkom het toevoegen van duplicaten, verhoog gewoon de hoeveelheid
const existingItemIndex = orderItems.findIndex(i => i.getProductId() === item.getProductId());
if (existingItemIndex > -1) {
const newQuantity = orderItems[existingItemIndex].getQuantity() + item.getQuantity();
// Hier zou je de hoeveelheid op het bestaande item bijwerken
// (Dit vereist dat items mutable zijn of een update methode hebben)
} else {
orderItems.push(item);
}
},
markPaid: () => {
if (status !== ORDER_STATUS.PENDING) {
throw new Error('Alleen pending orders kunnen als betaald worden gemarkeerd.');
}
// Retourneer een nieuwe Order instantie met de bijgewerkte status
return makeOrder({ id, customerId, items: orderItems, status: ORDER_STATUS.PAID, createdAt });
}
});
}
}
Deze `Order` entity dwingt nu complexe business rules af:
- Het beheert zijn eigen lijst met items.
- Het weet hoe het zijn eigen totaal moet berekenen.
- Het dwingt statusovergangen af (bijv., je kunt een `PENDING` order alleen als `PAID` markeren).
De business logica voor orders is nu netjes ingekapseld binnen deze module, testbaar in isolatie en herbruikbaar in je hele applicatie.
Geavanceerde Patronen en Overwegingen
Immutability: De Hoeksteen van Voorspelbaarheid
We hebben immutability aangeraakt. Waarom is het zo belangrijk? Wanneer objecten immutable zijn, kun je ze door je applicatie heen geven zonder bang te zijn dat een verre functie hun staat onverwachts zal veranderen. Dit elimineert een hele klasse bugs en maakt de data flow van je applicatie veel gemakkelijker te begrijpen.
Object.freeze() biedt een shallow freeze. Voor entities met geneste objecten of arrays (zoals onze `Order`), moet je voorzichtiger zijn. In `order.getItems()` hebben we bijvoorbeeld een kopie geretourneerd (`[...orderItems]`) om te voorkomen dat de aanroeper items rechtstreeks in de interne array van de order pusht.
Voor complexe applicaties kunnen libraries zoals Immer het werken met immutable geneste structuren veel gemakkelijker maken, maar het kernprincipe blijft: behandel je entities als immutable waarden. Wanneer er een verandering moet plaatsvinden, creƫer dan een nieuwe waarde.
Asynchrone Operaties en Persistentie Afhandelen
Je hebt misschien gemerkt dat onze entities volledig synchroon zijn. Ze weten niets van databases of API's. Dit is opzettelijk en een belangrijke kracht van het patroon!
Entities zouden zichzelf niet moeten opslaan. De taak van een entity is om business rules af te dwingen. De taak om data op te slaan in een database behoort tot een andere laag van je applicatie, vaak een Service Layer, Use Case Layer, of Repository Pattern genoemd.
Hier is hoe ze interageren:
// bestand: /use-cases/create-user.js
// Deze use case is afhankelijk van de user entity factory en een database toegang functie.
export default function makeCreateUser({ makeUser, usersDatabase }) {
return async function createUser(userInfo) {
// 1. Creƫer een geldige domein entity. Deze stap valideert de data.
const user = makeUser(userInfo);
// 2. Controleer op business rules die externe data vereisen (bijv., e-mail uniciteit)
const exists = await usersDatabase.findByEmail({ email: user.getEmail() });
if (exists) {
throw new Error('E-mailadres is al in gebruik.');
}
// 3. Persisteer de entity. De database heeft een plain object nodig.
const persisted = await usersDatabase.insert({
id: user.getId(),
firstName: user.getFirstName(),
// ... enzovoort
});
return persisted;
}
}
Deze scheiding van zorgen is krachtig:
- De `User` entity is puur, synchroon en gemakkelijk te unit testen.
- De `createUser` use case is verantwoordelijk voor orkestratie en kan integratie-getest worden met een mock database.
- De `usersDatabase` module is verantwoordelijk voor de specifieke database technologie en kan afzonderlijk worden getest.
Serialisatie en Deserialisatie
Je entities, met hun methoden, zijn rijke objecten. Maar wanneer je data over een netwerk verstuurt (bijv., in een JSON API response) of het in een database opslaat, heb je een plain data representatie nodig. Dit proces wordt serialisatie genoemd.
Een veelvoorkomend patroon is om een `toJSON()` of `toObject()` methode aan je entity toe te voegen.
// ... binnen de makeUser functie ...
return Object.freeze({
getId: () => id,
// ... andere getters
// Serialisatie methode
toObject: () => ({
id,
firstName,
lastName,
email: normalizedEmail,
createdAt
// Let op dat we de passwordHash niet opnemen
})
});
Het omgekeerde proces, het nemen van plain data uit een database of API en het terug transformeren naar een rijke domein entity, is precies waar je `makeUser` factory functie voor dient. Dit is deserialisatie.
Typing met TypeScript of JSDoc
Hoewel dit patroon perfect werkt in vanilla JavaScript, is het toevoegen van static types met TypeScript of JSDoc supercharged. Types stellen je in staat om formeel de 'vorm' van je entity te definiƫren, waardoor uitstekende autocompletion en compile-time checks worden geboden.
// bestand: /domain/user.ts
// Definieer de publieke interface van de entity
export type User = Readonly<{
getId: () => string;
getFirstName: () => string;
// ... etc
getFullName: () => string;
}>;
// De factory functie retourneert nu het User type
export default function buildMakeUser(...) {
return function makeUser(...): User {
// ... implementatie
}
}
De Overkoepelende Voordelen van het Module Entity Pattern
Door dit patroon aan te nemen, krijg je een veelvoud aan voordelen die zich opstapelen naarmate je applicatie groeit:
- Single Source of Truth: Business rules en data validatie zijn gecentraliseerd en ondubbelzinnig. Een wijziging aan een rule wordt op precies ƩƩn plaats gemaakt.
- Hoge Cohesie, Lage Koppeling: Entities zijn zelfstandig en hangen niet af van externe systemen. Dit maakt je codebase modulair en gemakkelijk te refactoren.
- Superieure Testbaarheid: Je kunt simpele, snelle unit tests schrijven voor je meest kritieke business logica zonder de hele wereld te mocken.
- Verbeterde Developer Experience: Wanneer een ontwikkelaar met een `User` moet werken, hebben ze een duidelijke, voorspelbare en zelf-documenterende API om te gebruiken. Geen gegis meer naar de vorm van plain objecten.
- Een Fundament voor Schaalbaarheid: Dit patroon geeft je een stabiele, betrouwbare kern. Naarmate je meer features, frameworks of UI componenten toevoegt, blijft je business logica beschermd en consistent.
Conclusie: Bouw een Solide Kern voor Je Applicatie
In een wereld van snel bewegende frameworks en libraries is het gemakkelijk om te vergeten dat deze tools van voorbijgaande aard zijn. Ze zullen veranderen. Wat blijft, is de kernlogica van je business domein. Het investeren van tijd in het correct modelleren van dit domein is niet zomaar een academische oefening; het is een van de belangrijkste lange-termijn investeringen die je kunt doen in de gezondheid en levensduur van je software.
Het JavaScript Module Entity Pattern biedt een simpele, krachtige en native manier om deze ideeĆ«n te implementeren. Het vereist geen zwaar framework of een complexe setup. Het maakt gebruik van de fundamentele features van de taalāmodules, functies en closuresāom je te helpen een schone, veerkrachtige en begrijpelijke kern voor je applicatie te bouwen. Begin met ƩƩn belangrijke entity in je volgende project. Model zijn eigenschappen, valideer zijn creatie en geef het gedrag. Je zet de eerste stap naar een robuustere en professionele software architectuur.